--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 72809efe812a11293d1e50d051ea72a1d2a09cab
Parents : 2eba734
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-30T12:44:26-05:00
feat(codec2): integrate Codec2 native library support in Android builds and workflows
Changes
11 files changed, 368 insertions(+), 2 deletions(-)
Diff
diff --git a/.github/workflows/android-apk-tag.yml b/.github/workflows/android-apk-tag.yml
index 051ca030..5388eb8b 100644
--- a/.github/workflows/android-apk-tag.yml
+++ b/.github/workflows/android-apk-tag.yml
@@ -177,6 +177,9 @@ jobs:
"pycodec2-*-cp311-cp311-android_24_arm64_v8a.whl"
"pycodec2-*-cp311-cp311-android_24_x86_64.whl"
"pycodec2-*-cp311-cp311-android_24_armeabi_v7a.whl"
+ "chaquopy_libcodec2-*-android_24_arm64_v8a.whl"
+ "chaquopy_libcodec2-*-android_24_x86_64.whl"
+ "chaquopy_libcodec2-*-android_24_armeabi_v7a.whl"
"lxst-*-py3-none-any.whl"
"bleak-*-py3-none-any.whl"
)
@@ -194,6 +197,13 @@ jobs:
fi
echo "All required Android wheels present:"
ls -1 android/vendor/
+ for wheel in android/vendor/pycodec2-*-android_24_*.whl; do
+ if ! unzip -l "${wheel}" | grep -q 'pycodec2/libcodec2\.so'; then
+ echo "::error::${wheel} is missing bundled pycodec2/libcodec2.so"
+ exit 1
+ fi
+ done
+ echo "pycodec2 wheels bundle libcodec2.so"
- name: Run unit tests
if: ${{ inputs.run_unit_tests }}
diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml
index 1cd5cfc4..013729f5 100644
--- a/.github/workflows/android-build.yml
+++ b/.github/workflows/android-build.yml
@@ -205,6 +205,9 @@ jobs:
"pycodec2-*-cp311-cp311-android_24_arm64_v8a.whl"
"pycodec2-*-cp311-cp311-android_24_x86_64.whl"
"pycodec2-*-cp311-cp311-android_24_armeabi_v7a.whl"
+ "chaquopy_libcodec2-*-android_24_arm64_v8a.whl"
+ "chaquopy_libcodec2-*-android_24_x86_64.whl"
+ "chaquopy_libcodec2-*-android_24_armeabi_v7a.whl"
"lxst-*-py3-none-any.whl"
"bleak-*-py3-none-any.whl"
)
@@ -222,6 +225,13 @@ jobs:
fi
echo "All required Android wheels present:"
ls -1 android/vendor/
+ for wheel in android/vendor/pycodec2-*-android_24_*.whl; do
+ if ! unzip -l "${wheel}" | grep -q 'pycodec2/libcodec2\.so'; then
+ echo "::error::${wheel} is missing bundled pycodec2/libcodec2.so"
+ exit 1
+ fi
+ done
+ echo "pycodec2 wheels bundle libcodec2.so"
- name: Run unit tests
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_tests }}
diff --git a/android/.gitignore b/android/.gitignore
index 396335d3..7fb782ae 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -16,3 +16,6 @@ local.properties
# Gradle-synced Chaquopy Python tree (see app/build.gradle)
/app/src/main/python/meshchatx/
+
+# libcodec2.so copied from vendor wheels (scripts/android/sync-codec2-jni-libs.sh)
+/app/src/main/jniLibs/*/libcodec2.so
diff --git a/android/README.md b/android/README.md
index 04067efa..69dcb2fc 100644
--- a/android/README.md
+++ b/android/README.md
@@ -50,7 +50,8 @@ See repo root `scripts/sign-android-apks.sh` (default glob targets `outputs/apk/
## Troubleshooting
1. Confirm `android/vendor/` contains required `.whl` files from the wheel build script.
-2. Run `./gradlew :app:assembleDebug` with `--stacktrace` if Python sync or Chaquopy pip steps fail.
-3. Re-run `./gradlew :app:assembleDebug` after changing `meshchatx/` assets; sync runs on merge Python sources tasks.
+2. Codec2 (voice messages, LXST Codec2 profiles): wheels must include `pycodec2/libcodec2.so` beside `pycodec2.so`. The wheel build script repacks automatically; for an existing `android/vendor/` tree run `python3 scripts/repack-android-pycodec2-wheels.py`. Gradle also runs this before sync and copies `libcodec2.so` into `jniLibs` per ABI.
+3. Run `./gradlew :app:assembleDebug` with `--stacktrace` if Python sync or Chaquopy pip steps fail.
+4. Re-run `./gradlew :app:assembleDebug` after changing `meshchatx/` assets; sync runs on merge Python sources tasks.
See [`../LICENSE`](../LICENSE) for full text and notices.
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 43243e64..b9d7eb3c 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,6 +91,40 @@ android {
}
}
+tasks.register("syncCodec2JniLibs", Exec) {
+ workingDir = repoRoot
+ def abiArg = selectedAndroidAbis.join(",")
+ commandLine(
+ "bash",
+ "scripts/android/sync-codec2-jni-libs.sh",
+ vendorWheelDir.absolutePath,
+ "${projectDir}/src/main/jniLibs",
+ abiArg
+ )
+ onlyIf {
+ vendorWheelDir.isDirectory() && vendorWheelDir.list()?.any { it.startsWith("chaquopy_libcodec2-") }
+ }
+}
+
+tasks.configureEach { task ->
+ if (task.name == "preBuild") {
+ task.dependsOn(tasks.named("syncCodec2JniLibs"))
+ }
+}
+
+tasks.register("repackAndroidPycodec2Wheels", Exec) {
+ workingDir = repoRoot
+ def pyExe = (System.getenv("MESHCHATX_REPACK_PYTHON") ?: "python3")
+ commandLine(pyExe, "scripts/repack-android-pycodec2-wheels.py", "--vendor-dir", vendorWheelDir.absolutePath)
+ onlyIf {
+ vendorWheelDir.isDirectory() && vendorWheelDir.list()?.any { it.startsWith("pycodec2-") && it.endsWith(".whl") }
+ }
+}
+
+tasks.named("syncCodec2JniLibs").configure {
+ dependsOn(tasks.named("repackAndroidPycodec2Wheels"))
+}
+
tasks.register("fetchRepositoryBundledWheels", Exec) {
workingDir = repoRoot
def pyExe = (System.getenv("MESHCHATX_FETCH_PYTHON") ?: "python3")
diff --git a/android/app/src/main/python/meshchat_wrapper.py b/android/app/src/main/python/meshchat_wrapper.py
index 488d4aec..937b0510 100644
--- a/android/app/src/main/python/meshchat_wrapper.py
+++ b/android/app/src/main/python/meshchat_wrapper.py
@@ -102,6 +102,12 @@ def start_server(port=8000, app_files_dir=None):
signal.signal = _safe_signal
asyncio_signal_patch = _patch_asyncio_signal_handlers_for_android()
aiohttp_run_app_patch = _patch_aiohttp_run_app_for_android()
+ try:
+ from meshchatx.android_codec2 import ensure_codec2_native_library
+
+ ensure_codec2_native_library()
+ except Exception as codec2_exc:
+ print(f"meshchat_wrapper: Codec2 preload skipped: {codec2_exc}")
from meshchatx.meshchat import ReticulumMeshChat, main
try:
diff --git a/meshchatx/android_codec2.py b/meshchatx/android_codec2.py
new file mode 100644
index 00000000..4ba3a40e
--- /dev/null
+++ b/meshchatx/android_codec2.py
@@ -0,0 +1,94 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Load Codec2 native libraries before pycodec2/LXST on Chaquopy Android."""
+
+from __future__ import annotations
+
+import ctypes
+import logging
+import sys
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+_codec2_preload_error: str | None = None
+_codec2_preload_done = False
+
+
+def _is_chaquopy_android() -> bool:
+ try:
+ import java # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def _libcodec2_candidates() -> list[Path]:
+ candidates: list[Path] = []
+ seen: set[str] = set()
+
+ def add(path: Path) -> None:
+ key = str(path)
+ if key in seen:
+ return
+ seen.add(key)
+ candidates.append(path)
+
+ try:
+ import pycodec2
+
+ add(Path(pycodec2.__file__).resolve().parent / "libcodec2.so")
+ except Exception:
+ pass
+
+ for entry in sys.path:
+ if not entry:
+ continue
+ add(Path(entry) / "chaquopy" / "lib" / "libcodec2.so")
+
+ return candidates
+
+
+def ensure_codec2_native_library() -> bool:
+ """Preload ``libcodec2.so`` so ``import pycodec2`` works on Android.
+
+ Chaquopy installs ``chaquopy-libcodec2`` separately from ``pycodec2``. The
+ extension module only declares a NEEDED entry for ``libcodec2.so``; without
+ preloading or bundling the shared library next to ``pycodec2.so``, imports
+ fail at runtime with ``dlopen`` errors.
+ """
+ global _codec2_preload_done, _codec2_preload_error
+
+ if _codec2_preload_done:
+ return _codec2_preload_error is None
+
+ _codec2_preload_done = True
+
+ if not _is_chaquopy_android():
+ return True
+
+ try:
+ ctypes.CDLL("libcodec2.so")
+ return True
+ except OSError:
+ pass
+
+ last_error: str | None = None
+ for lib_path in _libcodec2_candidates():
+ if not lib_path.is_file():
+ continue
+ try:
+ ctypes.CDLL(str(lib_path))
+ logger.info("Loaded Codec2 native library from %s", lib_path)
+ return True
+ except OSError as exc:
+ last_error = f"{lib_path}: {exc}"
+
+ _codec2_preload_error = last_error or "libcodec2.so not found on Android"
+ logger.warning("Codec2 native preload failed: %s", _codec2_preload_error)
+ return False
+
+
+def codec2_preload_error() -> str | None:
+ """Return the last preload failure message, if any."""
+ return _codec2_preload_error
diff --git a/scripts/android/sync-codec2-jni-libs.sh b/scripts/android/sync-codec2-jni-libs.sh
new file mode 100755
index 00000000..774fd377
--- /dev/null
+++ b/scripts/android/sync-codec2-jni-libs.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: 0BSD
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+VENDOR_DIR="${1:-${ROOT_DIR}/android/vendor}"
+JNI_LIBS_DIR="${2:-${ROOT_DIR}/android/app/src/main/jniLibs}"
+ABI_LIST="${3:-arm64-v8a,x86_64,armeabi-v7a}"
+
+abi_to_tag() {
+ case "$1" in
+ arm64-v8a) echo "arm64_v8a" ;;
+ x86_64) echo "x86_64" ;;
+ armeabi-v7a) echo "armeabi_v7a" ;;
+ *) echo "Unsupported ABI: $1" >&2; exit 1 ;;
+ esac
+}
+
+if [[ ! -d "${VENDOR_DIR}" ]]; then
+ echo "Missing vendor directory: ${VENDOR_DIR}" >&2
+ exit 1
+fi
+
+for abi in ${ABI_LIST//,/ }; do
+ tag="$(abi_to_tag "${abi}")"
+ lib_wheel="$(ls "${VENDOR_DIR}"/chaquopy_libcodec2-*-android_*_"${tag}".whl 2>/dev/null | tail -n 1 || true)"
+ if [[ -z "${lib_wheel}" ]]; then
+ echo "No chaquopy_libcodec2 wheel for ${abi} under ${VENDOR_DIR}" >&2
+ exit 1
+ fi
+ dest_dir="${JNI_LIBS_DIR}/${abi}"
+ mkdir -p "${dest_dir}"
+ unzip -p "${lib_wheel}" chaquopy/lib/libcodec2.so > "${dest_dir}/libcodec2.so"
+ echo "Synced ${dest_dir}/libcodec2.so from $(basename "${lib_wheel}")"
+done
diff --git a/scripts/build-android-wheels-local.sh b/scripts/build-android-wheels-local.sh
index 781c0729..631f1e23 100755
--- a/scripts/build-android-wheels-local.sh
+++ b/scripts/build-android-wheels-local.sh
@@ -578,6 +578,9 @@ mkdir -p "${OUT_DIR}"
cp -f "${PYPIDIR}/dist/chaquopy-libcodec2"/chaquopy_libcodec2-"${LIBCODEC2_VERSION}"-*.whl "${OUT_DIR}/"
cp -f "${PYPIDIR}/dist/pycodec2"/pycodec2-"${PYCODEC2_VERSION}"-*.whl "${OUT_DIR}/"
+echo "Bundling libcodec2.so into pycodec2 wheels (Android dlopen)"
+"${VENV_DIR}/bin/python" "${ROOT_DIR}/scripts/repack-android-pycodec2-wheels.py" --vendor-dir "${OUT_DIR}"
+
else
echo "Skipping pycodec2/chaquopy-libcodec2 builds (--only-recipes set)"
fi
diff --git a/scripts/repack-android-pycodec2-wheels.py b/scripts/repack-android-pycodec2-wheels.py
new file mode 100755
index 00000000..d94aacd1
--- /dev/null
+++ b/scripts/repack-android-pycodec2-wheels.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: 0BSD
+
+"""Bundle libcodec2.so into Chaquopy pycodec2 wheels (Android vendor dir)."""
+
+from __future__ import annotations
+
+import argparse
+import re
+import shutil
+import sys
+import tempfile
+import zipfile
+from pathlib import Path
+
+
+def _abi_tag_from_wheel_name(name: str) -> str | None:
+ match = re.search(r"android_\d+_(arm64_v8a|armeabi_v7a|x86_64)", name)
+ return match.group(1) if match else None
+
+
+def _extract_libcodec2(lib_wheel: Path, dest_dir: Path) -> Path | None:
+ with zipfile.ZipFile(lib_wheel) as zf:
+ for name in zf.namelist():
+ if name.endswith("chaquopy/lib/libcodec2.so"):
+ zf.extract(name, dest_dir)
+ return dest_dir / name
+ return None
+
+
+def _find_libcodec2(vendor_dir: Path, abi_tag: str, dest_dir: Path) -> Path | None:
+ matches = sorted(vendor_dir.glob(f"chaquopy_libcodec2-*-android_*_{abi_tag}.whl"))
+ if not matches:
+ return None
+ return _extract_libcodec2(matches[-1], dest_dir)
+
+
+def repack_pycodec2_wheel(pycodec2_wheel: Path, libcodec2_so: Path) -> bool:
+ if not libcodec2_so.is_file():
+ print(f"Missing libcodec2.so for {pycodec2_wheel.name}", file=sys.stderr)
+ return False
+
+ with tempfile.TemporaryDirectory(prefix="repack-pycodec2-") as tmp_dir:
+ root = Path(tmp_dir)
+ with zipfile.ZipFile(pycodec2_wheel) as zin:
+ zin.extractall(root)
+
+ target = root / "pycodec2" / "libcodec2.so"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(libcodec2_so, target)
+
+ tmp_wheel = pycodec2_wheel.with_suffix(".repack.whl")
+ with zipfile.ZipFile(
+ tmp_wheel,
+ "w",
+ compression=zipfile.ZIP_DEFLATED,
+ ) as zout:
+ for path in sorted(root.rglob("*")):
+ if path.is_file():
+ zout.write(path, path.relative_to(root).as_posix())
+
+ tmp_wheel.replace(pycodec2_wheel)
+ return True
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--vendor-dir",
+ type=Path,
+ default=Path("android/vendor"),
+ help="Directory containing pycodec2 and chaquopy_libcodec2 wheels",
+ )
+ args = parser.parse_args()
+ vendor_dir = args.vendor_dir.resolve()
+ if not vendor_dir.is_dir():
+ print(f"Vendor directory not found: {vendor_dir}", file=sys.stderr)
+ return 1
+
+ wheels = sorted(vendor_dir.glob("pycodec2-*-android_*.whl"))
+ if not wheels:
+ print(f"No pycodec2 Android wheels under {vendor_dir}", file=sys.stderr)
+ return 1
+
+ ok = True
+ for wheel in wheels:
+ abi_tag = _abi_tag_from_wheel_name(wheel.name)
+ if not abi_tag:
+ print(f"Skipping unrecognized wheel name: {wheel.name}", file=sys.stderr)
+ ok = False
+ continue
+ with tempfile.TemporaryDirectory(prefix="libcodec2-src-") as lib_tmp:
+ libcodec2 = _find_libcodec2(vendor_dir, abi_tag, Path(lib_tmp))
+ if libcodec2 is None:
+ print(f"No chaquopy_libcodec2 wheel for ABI {abi_tag}", file=sys.stderr)
+ ok = False
+ continue
+ if not repack_pycodec2_wheel(wheel, libcodec2):
+ ok = False
+ continue
+ print(f"Repacked {wheel.name} with pycodec2/libcodec2.so")
+
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/backend/test_android_codec2.py b/tests/backend/test_android_codec2.py
new file mode 100644
index 00000000..6caf4e56
--- /dev/null
+++ b/tests/backend/test_android_codec2.py
@@ -0,0 +1,63 @@
+# SPDX-License-Identifier: 0BSD
+
+from pathlib import Path
+from unittest.mock import patch
+
+import meshchatx.android_codec2 as android_codec2
+
+
+def test_ensure_codec2_skips_non_android():
+ android_codec2._codec2_preload_done = False
+ android_codec2._codec2_preload_error = None
+ with patch.object(android_codec2, "_is_chaquopy_android", return_value=False):
+ assert android_codec2.ensure_codec2_native_library() is True
+ assert android_codec2.codec2_preload_error() is None
+
+
+def test_ensure_codec2_loads_bundled_library(tmp_path):
+ lib = tmp_path / "libcodec2.so"
+ lib.write_bytes(b"\x7fELF")
+
+ android_codec2._codec2_preload_done = False
+ android_codec2._codec2_preload_error = None
+
+ with (
+ patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
+ patch.object(android_codec2.ctypes, "CDLL", side_effect=[OSError(), None]) as cdll,
+ patch.object(
+ android_codec2,
+ "_libcodec2_candidates",
+ return_value=[lib],
+ ),
+ ):
+ assert android_codec2.ensure_codec2_native_library() is True
+ cdll.assert_called_with(str(lib))
+
+
+def test_repack_script_bundles_libcodec2(tmp_path):
+ import importlib.util
+ import zipfile
+
+ repo_root = Path(__file__).resolve().parents[2]
+ script = repo_root / "scripts" / "repack-android-pycodec2-wheels.py"
+ spec = importlib.util.spec_from_file_location("repack_pycodec2", script)
+ repack_mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(repack_mod)
+ repack_pycodec2_wheel = repack_mod.repack_pycodec2_wheel
+
+ lib_wheel = tmp_path / "chaquopy_libcodec2-1.2.0-0-py3-none-android_24_arm64_v8a.whl"
+ py_wheel = tmp_path / "pycodec2-4.1.1-0-cp311-cp311-android_24_arm64_v8a.whl"
+
+ with zipfile.ZipFile(lib_wheel, "w") as zout:
+ zout.writestr("chaquopy/lib/libcodec2.so", b"\x7fELF-lib")
+
+ with zipfile.ZipFile(py_wheel, "w") as zout:
+ zout.writestr("pycodec2/pycodec2.so", b"\x7fELF-mod")
+
+ lib_src = tmp_path / "libcodec2.so"
+ lib_src.write_bytes(b"\x7fELF-lib")
+ assert repack_pycodec2_wheel(py_wheel, lib_src)
+
+ with zipfile.ZipFile(py_wheel) as zin:
+ names = zin.namelist()
+ assert "pycodec2/libcodec2.so" in names
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────